Skip to content

Replace textblob/nltk summary scoring with pure-Python heuristic#3571

Merged
jonathangreen merged 6 commits into
mainfrom
chore/drop-textblob-nltk
Jul 15, 2026
Merged

Replace textblob/nltk summary scoring with pure-Python heuristic#3571
jonathangreen merged 6 commits into
mainfrom
chore/drop-textblob-nltk

Conversation

@jonathangreen

@jonathangreen jonathangreen commented Jul 15, 2026

Copy link
Copy Markdown
Member

Description

Removes the textblob dependency (and its transitive nltk, joblib, regex, and tqdm packages) by reimplementing the one place they were used: SummaryEvaluator, which ranks a work's candidate descriptions during presentation calculation.

The evaluator now scores each summary on how well it covers the work's consensus vocabulary — content words (non-stopwords, via the repo's existing ENGLISH_STOPWORDS) that recur across at least two of the work's summaries — instead of TextBlob noun phrases. Sentence counting uses a regex instead of the NLTK tokenizer. The other scoring terms (bad-phrase penalties, the home-grown English-bigram language penalty) are unchanged. "All else being equal, shorter is better" is now an explicit tie-breaker rather than an accident of TextBlob's part-of-speech tagging.

Also removes the now-unneeded corpus-download steps from docker/Dockerfile.baseimage and tox.ini.

Motivation and Context

Both textblob and nltk are barely maintained, and nltk has an unpatched path-traversal vulnerability (nltk.data.load() decode-after-check via the nltk: URL scheme). They were the source of recurring dependency friction for a single, soft display-quality feature, so removing them outright is preferable to waiting on upstream fixes.

The noun-phrase signal being replaced only ever affected which description is displayed when a work has multiple, and only ~27%+ of described works have more than one candidate. The consensus-vocabulary replacement preserves the behavior that actually matters — an on-topic description still outranks generic marketing copy, which shares little vocabulary with the other descriptions — with no heavy dependencies.

How Has This Been Tested?

  • Reworked unit tests in tests/manager/core/test_summary_evaluator.py (7 passing), including a new case asserting generic marketing copy loses to an on-topic summary.
  • The evaluate_summary_quality integration test (tests/manager/sqlalchemy/model/test_edition.py) passes against Postgres/OpenSearch via tox -e py312-docker.
  • mypy is clean.

Checklist

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

TextBlob (and its transitive NLTK dependency) were used in exactly one
place: SummaryEvaluator, which ranks a work's candidate descriptions.
Both projects are barely maintained, and NLTK has an unpatched
path-traversal vulnerability, so this drops them entirely.

SummaryEvaluator now scores summaries on their coverage of the work's
consensus vocabulary -- content words recurring across at least two of
its summaries -- instead of TextBlob noun phrases, and counts sentences
with a regex instead of the NLTK tokenizer. This preserves the behavior
that matters: an on-topic description still outranks generic marketing
copy, which shares little vocabulary with the others. "Shorter is
better" is now an explicit tie-breaker rather than an accident of
TextBlob's part-of-speech tagging.

Removing textblob also drops nltk, joblib, regex, and tqdm, and lets us
delete the corpus-download steps from the Docker base image and tox.
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jonathangreen's task in 6m 16s —— View job


Code Review

  • Read the changed files (summary.py, tests, identifier.py, build files)
  • Analyze the new scoring heuristic for correctness
  • Check test coverage and edge cases
  • Post review findings

Summary

Clean, well-executed dependency removal. The consensus-vocabulary rewrite is mathematically sound: the coverage score is provably bounded to [0, 1] for any summary (a summary's content words are always a subset of the corpus's, and out-of-corpus words contribute 0 via Counter's __missing__), the zero-denominator case falls back to a neutral score, and the length term is correctly bounded to a ≤5% nudge that only flips genuine near-ties. I traced the four ranking tests and the test_edition.py integration test by hand and they all hold under the new logic; the old API (noun_phrases, blobs, _nltk_installed, parser=) has no remaining references, and the textblob/nltk/joblib/regex/tqdm removal is threaded cleanly through pyproject.toml, uv.lock, the Dockerfile, and tox.ini. The expanded test suite is a real improvement — it also fixes the pre-existing missing assertion in test_non_english_is_penalized. Just one minor tokenization refinement below.

Details

Nit: src/palace/manager/util/summary.py:107-111

Because _word_re ([a-z][a-z']*) keeps a trailing possessive apostrophe, a possessive and its base form tokenize to different content words — "Harry's"harry's, "Harry"harry. Character-name possessives are common in book blurbs and are exactly the recurring entities the consensus signal is meant to reward, so this splits their document-frequency (harry df=2 → harry/harry's df=1 each), weakening the very weighting the rewrite is built on. A small normalization that still preserves contractions:

for word in SummaryEvaluator._word_re.findall(text.lower()):
    word = word.removesuffix("'s").rstrip("'")  # "harry's" -> "harry", "cats'" -> "cats"; "don't" unchanged
    if len(word) > 2 and word not in ENGLISH_STOPWORDS:
        ...

return {
word
for word in SummaryEvaluator._word_re.findall(text.lower())
if len(word) > 2 and word not in ENGLISH_STOPWORDS
}

· chore/drop-textblob-nltk

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR removes the textblob and nltk dependencies (along with their transitive joblib, regex, and tqdm packages) by replacing the single point of use — SummaryEvaluator — with a pure-Python heuristic that scores candidate descriptions by how well they cover the work's consensus vocabulary (content words weighted by document-frequency²).

  • Core algorithm change (summary.py): noun-phrase extraction via TextBlob is replaced with a bag-of-words coverage score computed over ENGLISH_STOPWORDS-filtered tokens; NLTK sentence tokenization is replaced with a regex heuristic; a bounded length nudge (≤5%) is added as an explicit tiebreaker.
  • Infrastructure cleanup (Dockerfile.baseimage, tox.ini, uv.lock): corpus-download steps are removed and the five removed packages fall out of the lock file, shrinking image build time and eliminating the NLTK path-traversal CVE from the dependency surface.
  • Test suite (test_summary_evaluator.py): 7 new behavioural tests added, TextBlob mock tests removed, and a previously silent missing assertion in test_non_english_is_penalized is now actually checked.

Confidence Score: 5/5

Safe to merge; the change only affects which description is displayed when a work has multiple candidates, and the new algorithm is well-tested with no correctness concerns.

The algorithmic replacement is logically sound: coverage is always in [0, 1], the df² weighting naturally elevates recurring words, the length nudge is provably bounded, and the zero-content-word edge case is handled explicitly. The test suite exercises all meaningful paths including the previously untested non-English penalty assertion. Dependency removal is clean across pyproject.toml, uv.lock, Dockerfile, and tox.ini with no residual references.

No files require special attention.

Important Files Changed

Filename Overview
src/palace/manager/util/summary.py Core reimplementation of SummaryEvaluator: replaces TextBlob noun-phrase scoring with a pure-Python consensus-vocabulary approach using document-frequency-squared weights, regex sentence counting, and a bounded length nudge tiebreaker.
tests/manager/core/test_summary_evaluator.py Test suite fully rewritten (7 new tests, removes TextBlob mocks); also adds the missing assertion in test_non_english_is_penalized that was silently absent before this PR.
pyproject.toml Removes textblob==0.20.0 from dependencies and its mypy ignore entry; clean dependency removal with no unintended side effects.
docker/Dockerfile.baseimage Removes the 5-line NLTK corpus download/move/cleanup block; image build is now simpler and no longer needs to write to /usr/lib/nltk_data.
tox.ini Removes the commands_pre that downloaded the textblob/NLTK corpora before running tests.
uv.lock Removes lock entries for textblob, nltk, joblib, regex, and tqdm; lock file correctly updated to reflect dependency removal.
src/palace/manager/sqlalchemy/model/identifier.py Minor docstring update: 'noun phrases' → 'content words (its consensus vocabulary)' to match the new algorithm.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["add(summary)"] --> B["_content_words(summary)\n(regex tokenize → lowercase → filter stopwords + len≤2)"]
    B --> C["word_sets[summary] = words"]
    B --> D["content_words[word] += 1\n(document frequency counter)"]
    E["ready()"] --> F["total_word_weight = Σ count²\n(sum of df-squared weights)"]
    G["score(summary)"] --> H{total_word_weight > 0?}
    H -- Yes --> I["covered = Σ count[w]²\nfor w in summary words"]
    I --> J["coverage_score = covered / total_word_weight"]
    H -- No --> K["score = 1.0 (neutral)"]
    J --> L["Apply sentence-length penalty\n÷ off_from_optimal^1.5"]
    K --> L
    L --> M["Apply bad-phrase penalties\n× 0.5^bad_count"]
    M --> N["Apply language penalty\n× 0.5^(difference-1)"]
    N --> O["Apply length nudge\n× (1 - nudge × saturation)"]
    O --> P["Return final score"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["add(summary)"] --> B["_content_words(summary)\n(regex tokenize → lowercase → filter stopwords + len≤2)"]
    B --> C["word_sets[summary] = words"]
    B --> D["content_words[word] += 1\n(document frequency counter)"]
    E["ready()"] --> F["total_word_weight = Σ count²\n(sum of df-squared weights)"]
    G["score(summary)"] --> H{total_word_weight > 0?}
    H -- Yes --> I["covered = Σ count[w]²\nfor w in summary words"]
    I --> J["coverage_score = covered / total_word_weight"]
    H -- No --> K["score = 1.0 (neutral)"]
    J --> L["Apply sentence-length penalty\n÷ off_from_optimal^1.5"]
    K --> L
    L --> M["Apply bad-phrase penalties\n× 0.5^bad_count"]
    M --> N["Apply language penalty\n× 0.5^(difference-1)"]
    N --> O["Apply length nudge\n× (1 - nudge × saturation)"]
    O --> P["Return final score"]
Loading

Reviews (4): Last reviewed commit: "Make generic-copy test literally cover n..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.50%. Comparing base (87d1bfa) to head (8332c98).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3571      +/-   ##
==========================================
+ Coverage   93.47%   93.50%   +0.03%     
==========================================
  Files         512      512              
  Lines       46619    46640      +21     
  Branches     6353     6358       +5     
==========================================
+ Hits        43575    43612      +37     
+ Misses       1968     1959       -9     
+ Partials     1076     1069       -7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- Replace the unbounded length multiplier with a bounded nudge: length
  maps to a multiplier in [1 - length_nudge, 1] (length_nudge = 5%), so
  it can only break ties and never overturns a word-coverage difference
  (>= 10% per consensus word). Old 1/(1+len/n) shrank toward zero without
  limit, coupling its influence to absolute length.
- Update the evaluate_summary_quality docstring to describe consensus
  vocabulary instead of the removed noun-phrase mechanism.
- Add tests: length/coverage inversion regression, bounded-nudge
  guarantee, bytes input, dedup, unseen-summary scoring, custom vs
  default bad-phrase behavior, regex/dash penalties, empty best_choice
  (summary.py now 100%).
- Two-candidate works had an inert coverage signal: a word only reached
  the >= 2 'consensus' threshold when it appeared in both summaries, so
  every consensus word was trivially in both and each scored coverage
  1.0 (or the set was empty and both hit the neutral 1.0). Ranking then
  collapsed onto sentence count and the length nudge, letting a generic
  blurb win. Replace the binary consensus set with recurrence-weighted
  coverage (weight = document frequency squared): words shared across
  more summaries dominate, incidental words still count a little, and the
  signal stays meaningful for two-candidate works. Drops the now-unused
  top_words_to_consider parameter.
- Require word tokens to start with a letter so apostrophe-only runs
  like "'''" are no longer treated as content words.
- Add regression tests for the two-candidate case and the no-content-words
  corpus; update the readiness assertion to total_word_weight.
_content_words and _count_sentences are only used by SummaryEvaluator, so
make them static methods on the class, alongside the _word_re and
_sentence_end_re patterns they use (now class attributes). No behavior
change.
The generic blurb shared 'love' with the 'second' fixture, so it did
cover a recurring word and the comment's 'covers none of the top words'
claim was inaccurate. Swap 'love' for 'romance' so generic shares no
vocabulary with the other descriptions and the test asserts what it
claims.
@jonathangreen
jonathangreen requested a review from a team July 15, 2026 19:26

@tdilauro tdilauro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Welcome change! 🔥

@jonathangreen
jonathangreen merged commit 3a39802 into main Jul 15, 2026
25 checks passed
@jonathangreen
jonathangreen deleted the chore/drop-textblob-nltk branch July 15, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants